异常控制流
本章的核心内容:
- 了解什么是异常控制流(ECF)
- 了解系统中哪些功能是基于ECF实现的
什么是控制流?
程序计数器中指令的序列叫控制流
什么是突变?
指令是相邻的,我们说是“平滑的”
如果不相邻,就叫突变
引起突变的可能有跳转、调用、返回等指令,这些都是必要的突变
也有一些突变是系统状态的变化导致的
什么是异常控制流(ECF)?
这些指令的突变叫做异常控制流
异常控制流会发生在计算机系统的哪些层次?
硬件层:事件触发异常,控制流转移到异常处理程序
操作系统层:上下文切换让控制流在进程间切换
应用层:进程接受信号,控制流转移信号处理程序
了解ECF的好处?
- ECF是实现I/O,进程和虚拟内存的基本机制
- 理解程序和操作系统的交互,通过“陷阱”或者“系统调用”的ECF
- 编写shell程序,通过“上下文切换”的ECF
- 理解并发,ECF是实现并发的基本机制,如被中断的异常处理程序,时间上重叠的进程和线程,被中断后的信号处理程序
- 理解try、catch是怎么一回事,通过非本地跳转的ECF
异常
什么是异常?
ECF的一种形式,用来反映处理器状态的变化。
属于硬件层,但也有一部分由操作系统实现
什么是事件?比如?
处理器状态的变化称为事件
比如:虚拟内存缺页、算术溢出、除零
事件发生时如何处理?
由处理器来检测事件的发生
通过异常表跳转到异常处理程序
完成处理后,有3中选择:
- 返回到事件发生时正在执行的指令
- 返回到事件发生时的下一条指令
- 程序终止
异常和过程调用有什么区别?
- 异常有选择的把当前指令或者吓一跳指令压入栈
- 会把一些额外的处理器状态压入栈
- 如果控制流从用户程序转移到内核,那么栈是内核栈,而不是用户栈
- 异常处理程序运行在内核模式(见后文)
异常的类别?
中断(interrupt)、陷阱(trap)、故障(fault)、终止(abort)
中断
中断是异步发生的,是来自处理器外部I/O设备的信号的结果
所谓异步,是指并非由指令造成,而是任意时间的外部因素
流程:
陷阱和系统调用
陷阱是有意的异常(故意的跳转),是指令执行的结果
譬如:执行”syscall n”指令会导致一个到陷阱处理程序的跳转
由此可见,陷阱的一个重要用户的让用户程序调用内核函数,叫做系统调用
如:读文件、创建进程、加载进程、终止进程
什么是errno?
Linux中系统调用的错误都存储于errno中,errno由操作系统维护,存储就近发生的错误,即下一次的错误码会覆盖掉上一次的错误。
Linux中的strerror(errno)可以返回某个errno值的文本描述
故障
故障是由指令错误引起的
故障处理程序能处理,则重新执行指令,否则,返回到内核中的abort历程
终止
终止由不可恢复的致命错误导致,一般是硬件错误
譬如DRAM、SRAM位损坏
进程
异常控制流是操作系统内核得以提供进程
概念的基本构造块
什么是进程?
进程
就是执行中的一个程序实例
什么是上下文?
上下文
是一个进程运行所需要的各种状态
譬如:内存中的代码和数据、栈、寄存器、程序计数器、环境变量、打开文件描述符集合
一个进程的上下文可以分为三个部分:用户级上下文、寄存器上下文以及系统级上下文。
用户级上下文: 正文、数据、用户堆栈以及共享存储区;
寄存器上下文: 通用寄存器、程序寄存器(IP)、处理器状态寄存器(EFLAGS)、栈指针(ESP);
系统级上下文: 进程控制块task_struct、内存管理信息(mm_struct、vm_area_struct、pgd、pte)、内核栈。
进程提供给应用程序的两个抽象是?
- 一个独立的
逻辑控制流
- 一个私用的
虚拟地址空间
什么是逻辑控制流?
一个进程运行所独有的PC序列叫做逻辑流
什么是并发流?多任务?
两个在时间上有重叠的逻辑流称为并发流
从宏观上看,进程间的轮流执行叫多任务
什么是用户模式和内核模式?
为了进一步完善进程的概念,处理器需要提供一种机制,来限制进程能够执行的指令和能够访问的地址空间
处理器通过某个寄存器中的一个模式位为来提供这种机制,也就是用户模式和内核模式的概念
用户模式:无法执行特权指令(停止处理器,修改位模式),也无法访问内核地址空间中代码和数据(只能通过系统调用)
内核模式:能够执行所有和指令和访问机型所有的地址空间
进入内核模式的方式有哪些?
唯一的方式就是通过中断、陷阱这样的异常
linux的/proc文件系统有什么用?
提供一个后门,让进程在用户模式下安全的访问内核数据结构的内容(譬如CPU类型,内存段)
什么是调度?
内核决定在某时刻执行一个新的进程叫做调度
什么是上下文切换?
属于操作系统层的异常控制流,基于硬件层的异常控制流之上(所以上下文切换也是发生在内核模式),用来实现多任务
- 保存当前进程上下文
- 恢复另一个进程的上下文
- 控制转移
引起上下文切换的情况?
- 系统调用(陷阱):系统调用因为等待某个事件而发生阻塞,那么内核可以让当前进程休眠,切换到另一个进程;比如read文件阻塞被动休眠,再比如sleep主动休眠。
- 中断:所有的计算机都会有某种周期性的定时器中断机制,中断后进行上下文切换
系统调用错误处理
什么是错误包装函数?
对系统调用进行一次包装,包装函数中检查错误
为什么要使用错误包装函数?
系统调用出错会设置errno值
如果每次都去检查错误,代码会变得臃肿难懂
使用错误包装函数能使代码变得简洁
进程控制
每一个进程都有一个进程id(pid)
进程的三种状态是?
- 运行:正在CPU上执行或者等待被内核调度
- 停止:进程被挂起,无法被调度,等待被唤醒
- 终止:进程死掉了
有哪些因素会导致进程终止?
- 收到一个终止进程的信号
- 从主程序返回
- 调用exit函数
fork函数具有什么功能?
创建一个新的运行的子进程
fork后的子进程和父进程有什么关联和区别?
最大的区别就是PID不同
- 调用fork函数会有两次返回,父进程中返回子进程的PID,子进程中返回0
- 并发执行,新创建的子进程和父进程会并发的执行
- 相同但是独立的虚拟地址空间
- 共享文件,子进程可以读写父进程中打开的共享文件
子进程终止如何处理?
终止的进程不会立即在系统中消失,而是等待被父进程回收
如果父进程先终止,内核会安排init进程称为孤儿进程的“养父”
init进程pid为1,是所有进程的祖先
回收僵尸进程的相关函数?
1 | pid_t waitpid(pid_t pid, int *statusp, int options); |
等待一个集合中的子进程终止并回收
如果没有子进程,则返回-1,设置errno为ECHILD
如果被信号中断,则返回-1,设置errno为EINTR
- pid:等待集
- pid > 0:等待集是编号为pid的进程
- pid = -1:等待集是父进程所有的子进程
- options:修改函数行为
- 默认(0):挂机调用进程,直到等待集中的一个子进程终止
- WNOHANG:立即返回(都没终止返回0)
- WUNTRACED:挂机调用进程,直到等待集中的一个子进程终止或停止
- WCONTINUED:挂机调用进程,直到等待集中的一个子进程终止或者一个停止的子进程重新执行
- statusp:返回状态
- WIFEXITED(status):如果子进程正常结束,它就返回真;否则返回假。
- WEXITSTATUS(status):如果WIFEXITED(status)为真,则可以用该宏取得子进程exit()返回的结束代码。
- WIFSIGNALED(status):如果子进程因为一个未捕获的信号而终止,它就返回真;否则返回假。
- WTERMSIG(status):如果WIFSIGNALED(status)为真,则可以用该宏获得导致子进程终止的信号代码。
- WIFSTOPPED(status):如果当前子进程被暂停了,则返回真;否则返回假。
- WSTOPSIG(status):如果WIFSTOPPED(status)为真,则可以使用该宏获得导致子进程暂停的信号代码。
进程休眠的相关函数?
1 | unsigned int sleep(unsigned int secs); //等待时间到或者被信号中断 |
execve函数具有什么样的功能?
在当前的进程的上下文中加载并运行一个新程序
1 | int execve(const char *filename, const char *argv[], const char *envp[]) |
- filename:可执行的文件名
- argv[]:参数列表
- envp[]:环境变量列表
程序和进程有什么区别?
程序运行在进程的上下文中
案例:结合fork和execve实现一个简单的shell
信号
什么是信号?和异常的区别?
信号是应用层的异常,信号是进程上下文中的某个状态,对应一种底层事件
底层事件是处理器通知到内核,由内核异常处理程序处理的,这些用户进程是看不到的
信号是操作系统提供给进程的一种机制,让进程可以知道发生了这些异常事件
发送信号和接受信号的表现形式是什么?
- 发送信号:内核更新了进程上下文中的某个状态
- 检测到一个系统事件
- 调用了kill函数
- 接受信号:目的进程被内核强迫以某种方式处理信号,忽略、终止、或者执行信号处理程序
发个发出了但未接收的信号是待处理信号
每种类型的待处理信号只有一个,多于的呗丢弃
信号被阻塞,意味着信号可以被发送,但是不会被接收。
什么是进程组?
每个进程都只属于一个进程中
默认情况下,一个子进程继承父进程的进程组
1 | pid_t getpgrp(void) //获取当前进程的进程组id |
什么是作业
?
linux的一条命令行就是一个“作业”
同时只能有一个前台作业和多个后台作业
具体的发送信号的方式有哪些?
1 | linux>/bin/kill -9 (-)15213 //发送信号给进程(组) |
接受信号时机是什么时候?以及如何处理?
接受信号的时机是从内核模式切换到用户模式时(从系统调用返回或者上下文切换),去检查待处理且未阻塞的信号集合,选择其中的某个信号k
指定下面的某个行为:
- 终止
- 终止并转储内存
- 挂起,等待重启
- 忽略
- 信号处理程序
默认行为可以修改
如何修改信号的默认行为?
1 | /** |
具体的阻塞信号的方法?
- 隐式阻塞机制:正在被处理的信号默认被阻塞
- 显式阻塞机制:
1 | /** |
编写信号处理函数的难点是什么?
- 处理程序和主程序共享全局变量
- 接受信号的规则有违直觉
- 不同的系统由不同的信号处理语义
编写信号处理函数保守规则是什么?
- 处理程序尽可能简单
- 使用安全的函数
- 保存和恢复errno
- 阻塞其他的信号,保护对共享变量的访问
- 用volatile声明,立刻刷新主存
信号的接口规则会导致怎样的意外错误?
如果用信号处理程序来处理排队问题
由于信号的非排队机制会直接导致严重的错误
信号处理函数的系统兼容性问题是什么?
- signal语义不同:有的是一次性的,每次都需要去重新修改
- 有些系统调用可能会被信号处理中断,因此需要手动的去重启
如何解决这些兼容性问题?
使用signal的包装函数
什么情况下需要等待信号被接收?
比如,维护唯一的前台作业
如何显示的等待信号?
1 | int sigsuspend(const sigset_t *mask)//用mask暂时替换当前的阻塞集合,挂起并等待信号的接受 |
非本地跳转
什么是非本地跳转?
非本地跳转是一种用户级的异常控制流形式
可以从某一个函数的某处直接转移到另一个函数的某处,不需要进过栈进出1
2int setjmp(jmp_buf env) //在env缓存区保存当前的调用环境
int sigsetjmp(sigjmp_buf env, int retval) //从env中恢复调用环境,从setjmp返回,返回值是retval
非本地跳转有哪些重要的应用?
操作进程的工具
STRACE:打印出一个正在运行的进程和子进程调用的每个系统调用的轨迹
PS:列出当前所有的进程(包括僵尸进程)
TOP:打出当前进程资源使用信息
PMAP:显示进程的内存映射